package com.unitedats.common.util;

import static com.unitedats.common.util.DynamicQueryBuilder.Operator.BETWEEN;
import static com.unitedats.common.util.DynamicQueryBuilder.Operator.IN;
import static com.unitedats.common.util.DynamicQueryBuilder.Operator.LIKE;
import static javax.persistence.TemporalType.DATE;
import static javax.persistence.TemporalType.TIMESTAMP;
import static org.apache.commons.lang.StringUtils.capitalize;
import static org.apache.commons.lang.StringUtils.substringAfterLast;

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.sql.Timestamp;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import javax.persistence.EntityManager;
import javax.persistence.Id;
import javax.persistence.Query;
import javax.persistence.TypedQuery;

import com.unitedats.common.util.DynamicQueryBuilder.Operator.LikePattern;
import com.unitedats.common.util.DynamicQueryBuilder.Operator.OperType;



public final class DynamicQueryBuilder {

	private StringBuilder queryString = new StringBuilder(100);
	private Query query;
	private OperType operType = null;

	// public <T> List<T> find(EntityManager em,T beanType, Map<String,
	// Map<Operator, Object>> advancedParams,
	// Map<String, Boolean> orderBy, OperType operType) {
	//
	// DynamicQueryBuilder dyncQueryBuilder = new
	// DynamicQueryBuilder.Builder(em)
	// .select(beanType)
	// .withOperType(operType)
	// .orderBy(orderBy)
	// .withAdvancedParams(advancedParams)
	// .build();
	//
	// System.out.println(dyncQueryBuilder.getQueryString());
	// return dyncQueryBuilder.getQuery() .getResultList();
	// }

	/**
	 * Enum contains the query operators.
	 * 
	 * @author Mohamed Taman
	 */
	public enum Operator {
		LIKE("LIKE "), IN("IN "), BETWEEN("BETWEEN "), NE("!= "), GT("> "), LT("< "), GE(">= "), LE("=> ");

		/**
		 * Enum contains the like operator patterns.
		 * 
		 * @author Mohamed Taman
		 */
		public enum LikePattern {
			PERCENT_BEFORE, PERCENT_AFTER, PERCENT_AROUND;
		}

		/**
		 * Enum contains the main operators OR & AND.
		 * 
		 * @author Mohamed Taman
		 */
		public enum OperType {
			AND(" AND "), OR(" OR ");

			private String op;

			private OperType(String op) {
				this.op = op;
			}

			public String getOp() {
				return op;
			}
		}

		private String op;

		private Operator(String op) {
			this.op = op;
		}

		public String getOp() {
			return op;
		}

		/**
		 * @param value
		 *            to be adjusted by given pattern.
		 * @param pattern
		 *            to adjust value based on it.
		 * @return the modified value.
		 */
		public static String adjustLikeValue(String value, LikePattern pattern) {
			switch (pattern) {
			case PERCENT_AFTER:
				return value + "%";
			case PERCENT_BEFORE:
				return "%" + value;
			case PERCENT_AROUND:
				return "%" + value + "%";
			}
			return "";
		}
	}

	private Map<String, Map<Operator, Object>> advancedParams = new HashMap<String, Map<Operator, Object>>();

	private Map<String, String> orderByParams = new HashMap<String, String>();

	// store for final params and values
	private Map<String, Object> paramsAndValues = new HashMap<String, Object>();

	private DynamicQueryBuilder() {
	}

	/**
	 * @return the final constructed query string.
	 */
	public String getQueryString() {
		return queryString.toString().trim();
	}

	/**
	 * @return the prepared JPA query.
	 */
	public Query getQuery() {
		return query;
	}

	/**
	 * @param <t>
	 *            for bean type that is used to construct the query.
	 * @author Mohamed Taman
	 */
	public static class Builder<T> {
		private DynamicQueryBuilder dqb;
		private String queryObjChar = "";
		private EntityManager em;

		private T beanType;

		public Builder(EntityManager em) {
			this.em = em;
			dqb = new DynamicQueryBuilder();
		}

		/**
		 * This method accept the bean type to select by.
		 * 
		 * @param beanType
		 *            to select by.
		 * @return builder instance.
		 */
		public Builder select(T beanType) {
			if (beanType == null)
				throw new IllegalArgumentException("Bean should be passed.");

			this.beanType = beanType;
			queryObjChar = resolveName(beanType.getClass().getName()).substring(0, 1).toLowerCase();
			append("SELECT " + queryObjChar + " FROM " + resolveName(beanType.getClass().getName()) + " "
					+ queryObjChar);

			queryObjChar = queryObjChar + ".";

			return this;
		}

		/**
		 * This method add AND or OR operator to query.
		 * 
		 * @return builder instance.
		 */
		public Builder withOperType(OperType operType) {
			if (dqb.operType == null)
				dqb.operType = operType;

			return this;
		}

		/**
		 * This method add order by column and specify the order type.
		 * 
		 * @param param
		 *            column to order by.
		 * @param desc
		 *            is order type desc or asc.
		 * @return builder instance.
		 */
		public Builder orderBy(String param, boolean desc) {
			dqb.orderByParams.put(param, (desc ? "DESC" : "ASC"));
			return this;
		}

		/**
		 * This method add order by column and ASC by default.
		 * 
		 * @param param
		 *            column to order by.
		 * @return builder instance.
		 */
		public Builder orderBy(String param) {
			orderBy(param, false);
			return this;
		}

		/**
		 * This method add order by column and its order type.
		 * 
		 * @param orderByParams
		 *            map contains column and order type.
		 * @return builder instance.
		 */
		public Builder orderBy(Map<String, Boolean> orderByParams) {
			if (orderByParams != null)
				for (String key : orderByParams.keySet())
					orderBy(key, orderByParams.get(key));

			return this;
		}

		/**
		 * @param param
		 * @param operator
		 * @param extras
		 * @return
		 */
		public Builder withAdvancedParam(String param, Operator operator, Object extras)
				throws IllegalArgumentException {
			try {
				isValidAdvancedParam(operator, extras);
			} catch (IllegalArgumentException iae) {
				throw iae;
			}
			HashMap<Operator, Object> others = new HashMap<Operator, Object>();
			others.put(operator, extras);
			dqb.advancedParams.put(param, others);
			return this;
		}

		public Builder withAdvancedParam(String param, Operator operator) throws IllegalArgumentException {
			withAdvancedParam(param, operator, null);
			return this;
		}

		/**
		 * @param advancedParams
		 * @return
		 */
		public Builder withAdvancedParams(Map<String, Map<Operator, Object>> advancedParams) {
			if (advancedParams != null)
				dqb.advancedParams.putAll(advancedParams);
			return this;
		}

		/**
		 * @return the final
		 * @throws IllegalAccessException
		 */
		public DynamicQueryBuilder build() {
			try {
				// Create and prepare the named query and intialize the
				// parameters
				createQueryAndParams();
				// Resolve Advanced parameter if passed.
				resolveAdvancedParams();
				// Resolve order by parameter if passed.
				resolveOrderByParams();
				// Create, prepare and set the Query parameter
				prepareQueryParams();
			} catch (IllegalAccessException iae) {
				iae.printStackTrace();
			}
			return dqb;
		}

		private void append(String newVal) {
			dqb.queryString.append(newVal);
		}

		private void put(String key, Object value) {
			dqb.paramsAndValues.put(key, value);
		}

		private void createQueryAndParams() throws IllegalAccessException {
			// get required search fields
			Map<Field, SearchRequired> fields = getSearchFields();
			int i = 0; // for testing the last param, to not appent the operator

			if (fields.size() != 0) // If no fields then construct select all
									// query.
				append(" WHERE ");

			for (Field field : fields.keySet()) {
				i++;
				SearchRequired srAnnotation = fields.get(field);
				if (srAnnotation.isObject()) {
					// if field described as object get its fields
					Field[] subFields = field.getType().getDeclaredFields();
					// get the required search fields from the annotation fields
					// attribute
					String[] searchFields = srAnnotation.fields();
					if (searchFields.length == 0) { // if no fields then search
													// by the whole object id
						append(queryObjChar + field.getName() + " = :" + field.getName());
						put(field.getName(), field.get(beanType));
						if (i != fields.keySet().size())
							append(dqb.operType.getOp());

					} else
						// get the object required search fields
						for (String name : searchFields)
							for (Field subField : subFields) {
								if (subField.getName().equals(name)) { // if
																		// field
																		// is
																		// the
																		// required
																		// field
																		// then
																		// append
																		// it to
																		// query
									subField.setAccessible(true); // so we can
																	// access
																	// the
																	// private
																	// fields of
																	// the sub
																	// type
									if (isContainingValue(subField.get(field.get(beanType)))) {
										String fullName = field.getName() + capitalize(subField.getName());
										append(queryObjChar + field.getName() + "." + subField.getName() + " = :"
												+ fullName);
										put(fullName, subField.get(field.get(beanType)));
										if (i != fields.keySet().size())
											append(dqb.operType.getOp());
									}
									break;
								}
							}

				} else { // build normal bean type primitive attribute
					append(queryObjChar + field.getName() + " = :" + field.getName());
					put(field.getName(), field.get(beanType));
					if (i != fields.keySet().size())
						append(dqb.operType.getOp());
				}
			}
		}

		/**
		 * Method to check the values passed againest null.
		 * 
		 * @param value
		 *            to be checked.
		 * @return true if not null, elase false.
		 */

		private boolean isContainingValue(Object value) {
			boolean containsValue = false;
			if (value != null)
				if (value instanceof String)
					containsValue = !String.class.cast(value).trim().isEmpty();
				else
					containsValue = true;

			return containsValue;
		}

		/**
		 * Method to resolve a class name.
		 * 
		 * @param name
		 *            to be resolved.
		 * @return resolved name without packages
		 */

		private String resolveName(String name) {
			return substringAfterLast(name, ".");
		}

		private boolean isIdFieldContainsValue(Field[] subFields, Object type) throws IllegalAccessException {
			boolean containsValue = true;
			if (subFields != null)
				for (Field field : subFields)
					for (Annotation annotation : field.getDeclaredAnnotations())
						if (annotation instanceof Id) {
							field.setAccessible(true); // so we can access the
														// private fields for
														// bean type
							if (!isContainingValue(field.get(type)))
								containsValue = false;
						}

			return containsValue;
		}

		/**
		 * This method get all fields annotated with <code>SearchRequired</code>
		 * , and check if its contents is not null.
		 * 
		 * @return Map<Field, SearchRequired> of declared fields.
		 * @throws IllegalAccessException
		 * @see eg.com.common.annotation.search.SearchRequired
		 */

		private Map<Field, SearchRequired> getSearchFields() throws IllegalAccessException {
			HashMap<Field, SearchRequired> fields = new HashMap<Field, SearchRequired>();
			for (Field field : this.beanType.getClass().getDeclaredFields())
				for (Annotation annotation : field.getDeclaredAnnotations())
					if (annotation instanceof SearchRequired) {
						SearchRequired srAnno = SearchRequired.class.cast(annotation);
						field.setAccessible(true); // so we can access the
													// private fields for bean
													// type
						if (isContainingValue(field.get(beanType))) {
							if (srAnno.isObject()) {
								// "srAnno.fields().length != 0 ||" added by
								// Islam in 17/08/2014
								if (srAnno.fields().length != 0
										|| (srAnno.fields().length == 0 && isIdFieldContainsValue(field.getType()
												.getDeclaredFields(), field.get(beanType))))
									fields.put(field, srAnno);
							} else
								fields.put(field, srAnno);
						}
					}
			return fields;
		}

		private void resolveAdvancedParams() {
			if (dqb.advancedParams == null || dqb.advancedParams.isEmpty())
				return;

			for (String key : dqb.advancedParams.keySet()) {
				// if the pased param doesn't exist in filtered search param
				// continue.
				if (!dqb.paramsAndValues.containsKey(key))
					continue;

				Operator oper = dqb.advancedParams.get(key).keySet().iterator().next();
				Object extra = dqb.advancedParams.get(key).get(oper);
				handleAdvancedParamsOpers(key.trim(), oper, extra);
			}
		}

		private void resolveOrderByParams() {
			if (dqb.orderByParams == null || dqb.orderByParams.isEmpty())
				return;

			append(" ORDER BY ");
			int i = dqb.orderByParams.keySet().size();
			for (String key : dqb.orderByParams.keySet()) {
				i--;
				append(queryObjChar + key + " " + dqb.orderByParams.get(key));
				if (i > 0)
					append(", ");
			}
		}

		private void handleAdvancedParamsOpers(String key, Operator operator, Object extras) {

			int start = dqb.queryString.toString().indexOf(":" + key);

			dqb.queryString.replace(start - 2, start, operator.getOp());

			if (operator.equals(IN))
				dqb.paramsAndValues.put(key, extras);
			else if (operator.equals(LIKE))
				dqb.paramsAndValues
						.put(key,
								Operator.adjustLikeValue((String) dqb.paramsAndValues.get(key),
										LikePattern.class.cast(extras)));
			else if (operator.equals(BETWEEN)) {
				int queryKeyPosition = dqb.queryString.toString().indexOf(key);

				while (dqb.queryString.toString().charAt(queryKeyPosition) != queryObjChar.charAt(0))
					queryKeyPosition--;

				dqb.queryString.replace(queryKeyPosition - 1, queryKeyPosition, " (");

				queryKeyPosition = dqb.queryString.toString().indexOf(":" + key);

				dqb.queryString.replace(queryKeyPosition, queryKeyPosition + key.length() + 1, ":" + key + "From AND :"
						+ key + "To)");

				dqb.paramsAndValues.remove(key);
				Collection betweenValues = Collection.class.cast(extras);
				dqb.paramsAndValues.put(key + "From", betweenValues.iterator().next());
				dqb.paramsAndValues.put(key + "To", betweenValues.iterator().next());
			}
		}

		/**
		 * This method handles the parameters to be set in Query object and
		 * handle temporal types
		 * 
		 * @param parameters
		 */

		private void prepareQueryParams() {
			if (em == null)
				return;

			TypedQuery<T> query = em.createQuery(dqb.getQueryString(), (Class<T>) beanType.getClass());
			if (dqb.paramsAndValues != null || !dqb.paramsAndValues.isEmpty())
				for (String key : dqb.paramsAndValues.keySet()) {
					Object value = dqb.paramsAndValues.get(key);
					if (value instanceof Timestamp) {
						query.setParameter(key, Timestamp.class.cast(value), TIMESTAMP);
					} else if (value instanceof Date) {
						query.setParameter(key, Date.class.cast(value), DATE);
					} else {
						query.setParameter(key, value);
					}
				}
			dqb.query = query;
		}

		private void isValidAdvancedParam(Operator operator, Object value) throws IllegalArgumentException {
			switch (operator) {
			case LIKE:
				if (!(value instanceof Operator.LikePattern))
					throw new IllegalArgumentException(
							"You should use one value of 'Operator.LikePattern' enum with 'LIKE' operator.");
				break;
			case IN:
				if (!(value instanceof Collection))
					throw new IllegalArgumentException("You should use 'Collection' object with 'IN' operator.");
				break;
			case BETWEEN:
				if (value instanceof Collection) {
					Collection values = Collection.class.cast(value);
					if (values.size() != 2)
						throw new IllegalArgumentException(
								"The max values in 'Collection' object with 'BETWEEN' operator should be 2.");
				} else
					throw new IllegalArgumentException("You should use 'Collection' object with 'BETWEEN' operator.");
			}

		}
	}
}
